"""Several HTML builders."""

from __future__ import annotations

import contextlib
import html
import os
import os.path
import posixpath
import re
import shutil
import sys
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import quote

import docutils.readers.doctree
from docutils import nodes
from docutils.core import Publisher
from docutils.frontend import OptionParser
from docutils.io import DocTreeInput, StringOutput

from sphinx import __display_version__, package_dir
from sphinx import version_info as sphinx_version
from sphinx.builders import Builder
from sphinx.builders.html._assets import (
    _CascadingStyleSheet,
    _file_checksum,
    _JavaScript,
)
from sphinx.builders.html._build_info import BuildInfo
from sphinx.config import ENUM, Config
from sphinx.deprecation import _deprecation_warning
from sphinx.domains import Index, IndexEntry
from sphinx.environment.adapters.asset import ImageAdapter
from sphinx.environment.adapters.indexentries import IndexEntries
from sphinx.environment.adapters.toctree import document_toc, global_toctree_for_doc
from sphinx.errors import ConfigError, ThemeError
from sphinx.highlighting import PygmentsBridge
from sphinx.locale import _, __
from sphinx.search import js_index
from sphinx.theming import HTMLThemeFactory
from sphinx.util import logging
from sphinx.util._pathlib import _StrPath
from sphinx.util._timestamps import _format_rfc3339_microseconds
from sphinx.util._uri import is_url
from sphinx.util.console import bold
from sphinx.util.display import progress_message, status_iterator
from sphinx.util.docutils import new_document
from sphinx.util.fileutil import copy_asset
from sphinx.util.i18n import format_date
from sphinx.util.inventory import InventoryFile
from sphinx.util.matching import DOTFILES, Matcher, patmatch
from sphinx.util.osutil import (
    SEP,
    _last_modified_time,
    _relative_path,
    copyfile,
    ensuredir,
    relative_uri,
)
from sphinx.writers.html import HTMLWriter
from sphinx.writers.html5 import HTML5Translator

if TYPE_CHECKING:
    from collections.abc import Iterator, Set
    from typing import TypeAlias

    from docutils.nodes import Node
    from docutils.readers import Reader

    from sphinx.application import Sphinx
    from sphinx.environment import BuildEnvironment
    from sphinx.util.typing import ExtensionMetadata

#: the filename for the inventory of objects
INVENTORY_FILENAME = 'objects.inv'

logger = logging.getLogger(__name__)
return_codes_re = re.compile('[\r\n]+')

DOMAIN_INDEX_TYPE: TypeAlias = tuple[
    # Index name (e.g. py-modindex)
    str,
    # Index class
    type[Index],
    # list of (heading string, list of index entries) pairs.
    list[tuple[str, list[IndexEntry]]],
    # whether sub-entries should start collapsed
    bool,
]


def convert_locale_to_language_tag(locale: str | None) -> str | None:
    """Convert a locale string to a language tag (ex. en_US -> en-US).

    refs: BCP 47 (:rfc:`5646`)
    """
    if locale:
        return locale.replace('_', '-')
    else:
        return None


class StandaloneHTMLBuilder(Builder):
    """
    Builds standalone HTML docs.
    """

    name = 'html'
    format = 'html'
    epilog = __('The HTML pages are in %(outdir)s.')

    default_translator_class = HTML5Translator
    copysource = True
    allow_parallel = True
    out_suffix = '.html'
    link_suffix = '.html'  # defaults to matching out_suffix
    indexer_format: Any = js_index
    indexer_dumps_unicode = True
    # create links to original images from images [True/False]
    html_scaled_image_link = True
    supported_image_types = ['image/svg+xml', 'image/png', 'image/gif', 'image/jpeg']
    supported_remote_images = True
    supported_data_uri_images = True
    searchindex_filename = 'searchindex.js'
    add_permalinks = True
    allow_sharp_as_current_path = True
    embedded = False  # for things like HTML help or Qt help: suppresses sidebar
    search = True  # for things like HTML help and Apple help: suppress search
    use_index = False
    download_support = True  # enable download role

    imgpath: str = ''
    domain_indices: list[DOMAIN_INDEX_TYPE] = []

    def __init__(self, app: Sphinx, env: BuildEnvironment) -> None:
        super().__init__(app, env)

        # Static and asset directories
        self._static_dir = Path(self.outdir / '_static')
        self._sources_dir = Path(self.outdir / '_sources')
        self._downloads_dir = Path(self.outdir / '_downloads')
        self._images_dir = Path(self.outdir / '_images')

        # CSS files
        self._css_files: list[_CascadingStyleSheet] = []

        # JS files
        self._js_files: list[_JavaScript] = []

        # Cached Publisher for writing doctrees to HTML
        reader: Reader[DocTreeInput] = docutils.readers.doctree.Reader(
            parser_name='restructuredtext'
        )
        pub = Publisher(
            reader=reader,
            parser=reader.parser,
            writer=HTMLWriter(self),
            source_class=DocTreeInput,
            destination=StringOutput(encoding='unicode'),
        )
        pub.get_settings(output_encoding='unicode', traceback=True)
        self._publisher = pub

    def init(self) -> None:
        self.build_info = self.create_build_info()
        # basename of images directory
        self.imagedir = '_images'
        # section numbers for headings in the currently visited document
        self.secnumbers: dict[str, tuple[int, ...]] = {}
        # currently written docname
        self.current_docname: str = ''

        self.init_templates()
        self.init_highlighter()
        self.init_css_files()
        self.init_js_files()

        html_file_suffix = self.get_builder_config('file_suffix', 'html')
        if html_file_suffix is not None:
            self.out_suffix = html_file_suffix

        html_link_suffix = self.get_builder_config('link_suffix', 'html')
        if html_link_suffix is not None:
            self.link_suffix = html_link_suffix
        else:
            self.link_suffix = self.out_suffix

        self.use_index = self.get_builder_config('use_index', 'html')

    def create_build_info(self) -> BuildInfo:
        return BuildInfo(self.config, self.tags, frozenset({'html'}))

    def _get_translations_js(self) -> Path | None:
        for dir_ in self.config.locale_dirs:
            js_file = Path(dir_, self.config.language, 'LC_MESSAGES', 'sphinx.js')
            if js_file.is_file():
                return js_file

        js_file = Path(
            package_dir, 'locale', self.config.language, 'LC_MESSAGES', 'sphinx.js'
        )
        if js_file.is_file():
            return js_file

        js_file = Path(
            sys.prefix, 'share', 'sphinx', 'locale', self.config.language, 'sphinx.js'
        )
        if js_file.is_file():
            return js_file

        return None

    def _get_style_filenames(self) -> Iterator[str]:
        if isinstance(self.config.html_style, str):
            yield self.config.html_style
        elif self.config.html_style is not None:
            yield from self.config.html_style
        elif self.theme:
            yield from self.theme.stylesheets
        else:
            yield 'default.css'

    def get_theme_config(self) -> tuple[str, dict[str, str | int | bool]]:
        return self.config.html_theme, self.config.html_theme_options

    def init_templates(self) -> None:
        theme_factory = HTMLThemeFactory(self.app)
        theme_name, theme_options = self.get_theme_config()
        self.theme = theme_factory.create(theme_name)
        self.theme_options = theme_options
        self.create_template_bridge()
        self.templates.init(self, self.theme)

    def init_highlighter(self) -> None:
        # determine Pygments style and create the highlighter
        if self.config.pygments_style is not None:
            style = self.config.pygments_style
        elif self.theme:
            # From the ``pygments_style`` theme setting
            style = self.theme.pygments_style_default or 'none'
        else:
            style = 'sphinx'
        self.highlighter = PygmentsBridge('html', style)

        if self.theme:
            # From the ``pygments_dark_style`` theme setting
            dark_style = self.theme.pygments_style_dark
        else:
            dark_style = None

        self.dark_highlighter: PygmentsBridge | None
        if dark_style is not None:
            self.dark_highlighter = PygmentsBridge('html', dark_style)
            self.app.add_css_file(
                'pygments_dark.css',
                media='(prefers-color-scheme: dark)',
                id='pygments_dark_css',
            )
        else:
            self.dark_highlighter = None

    @property
    def css_files(self) -> list[_CascadingStyleSheet]:
        _deprecation_warning(
            __name__, f'{self.__class__.__name__}.css_files', remove=(9, 0)
        )
        return self._css_files

    def init_css_files(self) -> None:
        self._css_files = []
        self.add_css_file('pygments.css', priority=200)

        for filename in self._get_style_filenames():
            self.add_css_file(filename, priority=200)

        for filename, attrs in self.app.registry.css_files:
            self.add_css_file(filename, **attrs)

        for filename, attrs in self.get_builder_config('css_files', 'html'):
            # User's CSSs are loaded after extensions'
            attrs.setdefault('priority', 800)
            self.add_css_file(filename, **attrs)

    def add_css_file(self, filename: str, **kwargs: Any) -> None:
        if '://' not in filename:
            filename = posixpath.join('_static', filename)

        if (asset := _CascadingStyleSheet(filename, **kwargs)) not in self._css_files:
            self._css_files.append(asset)

    @property
    def script_files(self) -> list[_JavaScript]:
        canonical_name = f'{self.__class__.__name__}.script_files'
        _deprecation_warning(__name__, canonical_name, remove=(9, 0))
        return self._js_files

    def init_js_files(self) -> None:
        self._js_files = []
        self.add_js_file('documentation_options.js', priority=200)
        self.add_js_file('doctools.js', priority=200)
        self.add_js_file('sphinx_highlight.js', priority=200)

        for filename, attrs in self.app.registry.js_files:
            self.add_js_file(filename or '', **attrs)

        for filename, attrs in self.get_builder_config('js_files', 'html'):
            attrs.setdefault('priority', 800)  # User's JSs are loaded after extensions'
            self.add_js_file(filename or '', **attrs)

        if self._get_translations_js():
            self.add_js_file('translations.js')

    def add_js_file(self, filename: str, **kwargs: Any) -> None:
        if filename and '://' not in filename:
            filename = posixpath.join('_static', filename)

        if (asset := _JavaScript(filename, **kwargs)) not in self._js_files:
            self._js_files.append(asset)

    @property
    def math_renderer_name(self) -> str | None:
        name = self.get_builder_config('math_renderer', 'html')
        if name is not None:
            # use given name
            return name
        else:
            # not given: choose a math_renderer from registered ones as possible
            renderers = list(self.app.registry.html_inline_math_renderers)
            if len(renderers) == 1:
                # only default math_renderer (mathjax) is registered
                return renderers[0]
            elif len(renderers) == 2:
                # default and another math_renderer are registered; prior the another
                renderers.remove('mathjax')
                return renderers[0]
            else:
                # many math_renderers are registered. can't choose automatically!
                return None

    def get_outdated_docs(self) -> Iterator[str]:
        build_info_path = self.outdir / '.buildinfo'
        try:
            build_info = BuildInfo.load(build_info_path)
        except ValueError as exc:
            logger.warning(__('Failed to read build info file: %r'), exc)
        except OSError:
            # ignore errors on reading
            pass
        else:
            if self.build_info != build_info:
                # log the mismatch and backup the old build info
                build_info_backup = build_info_path.with_name('.buildinfo.bak')
                try:
                    shutil.move(build_info_path, build_info_backup)
                    self.build_info.dump(build_info_path)
                except OSError:
                    pass  # ignore errors
                else:
                    # only log on success
                    msg = __(
                        'build_info mismatch, copying .buildinfo to .buildinfo.bak'
                    )
                    logger.info(bold(__('building [html]: ')) + msg)

                yield from self.env.found_docs
                return

        if self.templates:
            template_mtime = int(self.templates.newest_template_mtime() * 10**6)
            try:
                old_mtime = _last_modified_time(build_info_path)
            except Exception:
                pass
            else:
                # Let users know they have a newer template
                if template_mtime > old_mtime:
                    logger.info(
                        bold('building [html]: ')
                        + __(
                            'template %s has been changed since the previous build, '
                            'all docs will be rebuilt'
                        ),
                        self.templates.newest_template_name(),
                    )
        else:
            template_mtime = 0
        for docname in self.env.found_docs:
            if docname not in self.env.all_docs:
                logger.debug('[build target] did not in env: %r', docname)
                yield docname
                continue
            target_name = self.get_output_path(docname)
            try:
                target_mtime = _last_modified_time(target_name)
            except OSError:
                target_mtime = 0
            try:
                doc_mtime = _last_modified_time(self.env.doc2path(docname))
                srcmtime = max(doc_mtime, template_mtime)
                if srcmtime > target_mtime:
                    logger.debug(
                        '[build target] target_name %r(%s), template(%s), docname %r(%s)',
                        target_name,
                        _format_rfc3339_microseconds(target_mtime),
                        _format_rfc3339_microseconds(template_mtime),
                        docname,
                        _format_rfc3339_microseconds(doc_mtime),
                    )
                    yield docname
            except OSError:
                # source doesn't exist anymore
                pass

    def get_asset_paths(self) -> list[str]:
        return self.config.html_extra_path + self.config.html_static_path

    def render_partial(self, node: Node | None) -> dict[str, str]:
        """Utility: Render a lone doctree node."""
        if node is None:
            return {'fragment': ''}

        doc = new_document('<partial node>')
        doc.append(node)
        self._publisher.set_source(doc)
        self._publisher.publish()
        return self._publisher.writer.parts

    def prepare_writing(self, docnames: Set[str]) -> None:
        # create the search indexer
        self.indexer = None
        if self.search:
            from sphinx.search import IndexBuilder

            lang = self.config.html_search_language or self.config.language
            self.indexer = IndexBuilder(
                self.env,
                lang,
                self.config.html_search_options,
                self.config.html_search_scorer,
            )
            self.load_indexer(docnames)

        self.docwriter = HTMLWriter(self)
        with warnings.catch_warnings():
            warnings.filterwarnings('ignore', category=DeprecationWarning)
            # DeprecationWarning: The frontend.OptionParser class will be replaced
            # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later.
            self.docsettings: Any = OptionParser(
                defaults=self.env.settings,
                components=(self.docwriter,),
                read_config_files=True,
            ).get_default_values()
        self.docsettings.compact_lists = bool(self.config.html_compact_lists)

        # determine the additional indices to include
        self.domain_indices = []
        # html_domain_indices can be False/True or a list of index names
        if indices_config := self.config.html_domain_indices:
            if not isinstance(indices_config, bool):
                check_names = True
                indices_config = frozenset(indices_config)
            else:
                check_names = False
            for domain in self.env.domains.sorted():
                for index_cls in domain.indices:
                    index_name = f'{domain.name}-{index_cls.name}'
                    if check_names and index_name not in indices_config:
                        continue
                    content, collapse = index_cls(domain).generate()
                    if content:
                        self.domain_indices.append((
                            index_name,
                            index_cls,
                            content,
                            collapse,
                        ))

        # format the "last updated on" string, only once is enough since it
        # typically doesn't include the time of day
        last_updated: str | None
        if (lu_fmt := self.config.html_last_updated_fmt) is not None:
            last_updated = format_date(
                lu_fmt or _('%b %d, %Y'),
                language=self.config.language,
                local_time=not self.config.html_last_updated_use_utc,
            )
        else:
            last_updated = None

        # If the logo or favicon are urls, keep them as-is, otherwise
        # strip the relative path as the files will be copied into _static.
        logo = self.config.html_logo or ''
        favicon = self.config.html_favicon or ''

        if not is_url(logo):
            logo = os.path.basename(logo)
        if not is_url(favicon):
            favicon = os.path.basename(favicon)

        self.relations = self.env.collect_relations()

        rellinks: list[tuple[str, str, str, str]] = []
        if self.use_index:
            rellinks.append(('genindex', _('General Index'), 'I', _('index')))
        for index_name, index_cls, _content, _collapse in self.domain_indices:
            # if it has a short name
            if index_cls.shortname:
                rellinks.append((
                    index_name,
                    index_cls.localname,
                    '',
                    index_cls.shortname,
                ))

        # add assets registered after ``Builder.init()``.
        for css_filename, attrs in self.app.registry.css_files:
            self.add_css_file(css_filename, **attrs)
        for js_filename, attrs in self.app.registry.js_files:
            self.add_js_file(js_filename or '', **attrs)

        # back up _css_files and _js_files to allow adding CSS/JS files to a specific page.
        self._orig_css_files = list(dict.fromkeys(self._css_files))
        self._orig_js_files = list(dict.fromkeys(self._js_files))
        styles = list(self._get_style_filenames())

        self.globalcontext = {
            'embedded': self.embedded,
            'project': self.config.project,
            'release': return_codes_re.sub('', self.config.release),
            'version': self.config.version,
            'last_updated': last_updated,
            'copyright': self.config.copyright,
            'master_doc': self.config.root_doc,
            'root_doc': self.config.root_doc,
            'use_opensearch': self.config.html_use_opensearch,
            'docstitle': self.config.html_title,
            'shorttitle': self.config.html_short_title,
            'show_copyright': self.config.html_show_copyright,
            'show_search_summary': self.config.html_show_search_summary,
            'show_sphinx': self.config.html_show_sphinx,
            'has_source': self.config.html_copy_source,
            'show_source': self.config.html_show_sourcelink,
            'sourcelink_suffix': self.config.html_sourcelink_suffix,
            'file_suffix': self.out_suffix,
            'link_suffix': self.link_suffix,
            'script_files': self._js_files,
            'language': convert_locale_to_language_tag(self.config.language),
            'css_files': self._css_files,
            'sphinx_version': __display_version__,
            'sphinx_version_tuple': sphinx_version,
            'docutils_version_info': docutils.__version_info__[:5],
            'styles': styles,
            'rellinks': rellinks,
            'builder': self.name,
            'parents': [],
            'logo_url': logo,
            'logo_alt': _('Logo of %s') % self.config.project,
            'favicon_url': favicon,
            'html5_doctype': True,
        }
        if self.theme:
            self.globalcontext |= {
                f'theme_{key}': val
                for key, val in self.theme.get_options(self.theme_options).items()
            }
        self.globalcontext |= self.config.html_context

        if self.copysource:
            # Create _sources
            ensuredir(self._sources_dir)

    def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, Any]:
        """Collect items for the template context of a page."""
        # find out relations
        prev = next = None
        parents = []
        rellinks = self.globalcontext['rellinks'][:]
        related = self.relations.get(docname)
        titles = self.env.titles
        if related and related[2]:
            try:
                next = {
                    'link': self.get_relative_uri(docname, related[2]),
                    'title': self.render_partial(titles[related[2]])['title'],
                }
                rellinks.append((related[2], next['title'], 'N', _('next')))
            except KeyError:
                next = None
        if related and related[1]:
            try:
                prev = {
                    'link': self.get_relative_uri(docname, related[1]),
                    'title': self.render_partial(titles[related[1]])['title'],
                }
                rellinks.append((related[1], prev['title'], 'P', _('previous')))
            except KeyError:
                # the relation is (somehow) not in the TOC tree, handle
                # that gracefully
                prev = None
        while related and related[0]:
            with contextlib.suppress(KeyError):
                parents.append({
                    'link': self.get_relative_uri(docname, related[0]),
                    'title': self.render_partial(titles[related[0]])['title'],
                })

            related = self.relations.get(related[0])
        if parents:
            # remove link to the master file; we have a generic
            # "back to index" link already
            parents.pop()
        parents.reverse()

        # title rendered as HTML
        title_node = self.env.longtitles.get(docname)
        title = self.render_partial(title_node)['title'] if title_node else ''

        # Suffix for the document
        source_suffix = str(self.env.doc2path(docname, False))[len(docname) :]

        # the name for the copied source
        if self.config.html_copy_source:
            sourcename = docname + source_suffix
            if source_suffix != self.config.html_sourcelink_suffix:
                sourcename += self.config.html_sourcelink_suffix
        else:
            sourcename = ''

        # metadata for the document
        meta = self.env.metadata.get(docname)

        # local TOC and global TOC tree
        self_toc = document_toc(self.env, docname, self.tags)
        toc = self.render_partial(self_toc)['fragment']

        return {
            'parents': parents,
            'prev': prev,
            'next': next,
            'title': title,
            'meta': meta,
            'body': body,
            'metatags': metatags,
            'rellinks': rellinks,
            'sourcename': sourcename,
            'toc': toc,
            # only display a TOC if there's more than one item to show
            'display_toc': (self.env.toc_num_entries[docname] > 1),
            'page_source_suffix': source_suffix,
        }

    def copy_assets(self) -> None:
        self.finish_tasks.add_task(self.copy_download_files)
        self.finish_tasks.add_task(self.copy_static_files)
        self.finish_tasks.add_task(self.copy_extra_files)
        self.finish_tasks.join()

    def write_doc(self, docname: str, doctree: nodes.document) -> None:
        destination = StringOutput(encoding='utf-8')
        doctree.settings = self.docsettings

        self.secnumbers = self.env.toc_secnumbers.get(docname, {})
        self.fignumbers = self.env.toc_fignumbers.get(docname, {})
        self.imgpath = relative_uri(self.get_target_uri(docname), '_images')
        self.dlpath = relative_uri(self.get_target_uri(docname), '_downloads')
        self.current_docname = docname
        self.docwriter.write(doctree, destination)
        self.docwriter.assemble_parts()
        body = self.docwriter.parts['fragment']
        metatags = self.docwriter.clean_meta

        ctx = self.get_doc_context(docname, body, metatags)
        self.handle_page(docname, ctx, event_arg=doctree)

    def write_doc_serialized(self, docname: str, doctree: nodes.document) -> None:
        self.imgpath = relative_uri(self.get_target_uri(docname), self.imagedir)
        self.post_process_images(doctree)
        title_node = self.env.longtitles.get(docname)
        title = self.render_partial(title_node)['title'] if title_node else ''
        self.index_page(docname, doctree, title)

    def finish(self) -> None:
        self.finish_tasks.add_task(self.gen_indices)
        self.finish_tasks.add_task(self.gen_pages_from_extensions)
        self.finish_tasks.add_task(self.gen_additional_pages)
        self.finish_tasks.add_task(self.copy_image_files)
        self.finish_tasks.add_task(self.write_buildinfo)

        # dump the search index
        self.handle_finish()

    @progress_message(__('generating indices'))
    def gen_indices(self) -> None:
        # the global general index
        if self.use_index:
            self.write_genindex()

        # the global domain-specific indices
        self.write_domain_indices()

    def gen_pages_from_extensions(self) -> None:
        # pages from extensions
        for pagelist in self.events.emit('html-collect-pages'):
            for pagename, context, template in pagelist:
                self.handle_page(pagename, context, template)

    @progress_message(__('writing additional pages'))
    def gen_additional_pages(self) -> None:
        # additional pages from conf.py
        for pagename, template in self.config.html_additional_pages.items():
            logger.info(pagename + ' ', nonl=True)
            self.handle_page(pagename, {}, template)

        # the search page
        if self.search:
            logger.info('search ', nonl=True)
            self.handle_page('search', {}, 'search.html')

        # the opensearch xml file
        if self.config.html_use_opensearch and self.search:
            logger.info('opensearch ', nonl=True)
            self.handle_page(
                'opensearch',
                {},
                'opensearch.xml',
                outfilename=self._static_dir / 'opensearch.xml',
            )

    def write_genindex(self) -> None:
        # the total count of lines for each index letter, used to distribute
        # the entries into two columns
        genindex = IndexEntries(self.env).create_index(self)
        indexcounts = [
            sum(1 + len(subitems) for _, (_, subitems, _) in entries)
            for _k, entries in genindex
        ]

        genindexcontext = {
            'genindexentries': genindex,
            'genindexcounts': indexcounts,
            'split_index': self.config.html_split_index,
        }
        logger.info('genindex ', nonl=True)

        if self.config.html_split_index:
            self.handle_page('genindex', genindexcontext, 'genindex-split.html')
            self.handle_page('genindex-all', genindexcontext, 'genindex.html')
            for (key, entries), count in zip(genindex, indexcounts, strict=True):
                ctx = {
                    'key': key,
                    'entries': entries,
                    'count': count,
                    'genindexentries': genindex,
                }
                self.handle_page('genindex-' + key, ctx, 'genindex-single.html')
        else:
            self.handle_page('genindex', genindexcontext, 'genindex.html')

    def write_domain_indices(self) -> None:
        for index_name, index_cls, content, collapse in self.domain_indices:
            index_context = {
                'indextitle': index_cls.localname,
                'content': content,
                'collapse_index': collapse,
            }
            logger.info('%s ', index_name, nonl=True)
            self.handle_page(index_name, index_context, 'domainindex.html')

    def copy_image_files(self) -> None:
        if self.images:
            stringify_func = ImageAdapter(self.app.env).get_original_image_uri
            ensuredir(self._images_dir)
            for src in status_iterator(
                self.images,
                __('copying images... '),
                'brown',
                len(self.images),
                self.app.verbosity,
                stringify_func=stringify_func,
            ):
                dest = self.images[src]
                try:
                    copyfile(
                        self.srcdir / src,
                        self._images_dir / dest,
                        force=True,
                    )
                except Exception as err:
                    logger.warning(
                        __("cannot copy image file '%s': %s"), self.srcdir / src, err
                    )

    def copy_download_files(self) -> None:
        def to_relpath(f: str) -> str:
            return _relative_path(Path(f), self.srcdir).as_posix()

        # copy downloadable files
        if self.env.dlfiles:
            ensuredir(self._downloads_dir)
            for src in status_iterator(
                self.env.dlfiles,
                __('copying downloadable files... '),
                'brown',
                len(self.env.dlfiles),
                self.app.verbosity,
                stringify_func=to_relpath,
            ):
                try:
                    dest = self._downloads_dir / self.env.dlfiles[src][1]
                    ensuredir(dest.parent)
                    copyfile(self.srcdir / src, dest, force=True)
                except OSError as err:
                    logger.warning(
                        __('cannot copy downloadable file %r: %s'),
                        self.srcdir / src,
                        err,
                    )

    def create_pygments_style_file(self) -> None:
        """Create a style file for pygments."""
        pyg_path = self._static_dir / 'pygments.css'
        with open(pyg_path, 'w', encoding='utf-8') as f:
            f.write(self.highlighter.get_stylesheet())

        if self.dark_highlighter:
            dark_path = self._static_dir / 'pygments_dark.css'
            with open(dark_path, 'w', encoding='utf-8') as f:
                f.write(self.dark_highlighter.get_stylesheet())

    def copy_translation_js(self) -> None:
        """Copy a JavaScript file for translations."""
        if js_file := self._get_translations_js():
            copyfile(
                js_file,
                self._static_dir / 'translations.js',
                force=True,
            )

    def copy_stemmer_js(self) -> None:
        """Copy a JavaScript file for stemmer."""
        if self.indexer is not None:
            if hasattr(self.indexer, 'get_js_stemmer_rawcodes'):
                for jsfile in self.indexer.get_js_stemmer_rawcodes():
                    js_path = Path(jsfile)
                    copyfile(
                        js_path,
                        self._static_dir / js_path.name,
                        force=True,
                    )
            else:
                if js_stemmer_rawcode := self.indexer.get_js_stemmer_rawcode():
                    copyfile(
                        js_stemmer_rawcode,
                        self._static_dir / '_stemmer.js',
                        force=True,
                    )

    def copy_theme_static_files(self, context: dict[str, Any]) -> None:
        def onerror(filename: str, error: Exception) -> None:
            msg = __("Failed to copy a file in the theme's 'static' directory: %s: %r")
            logger.warning(msg, filename, error)

        if self.theme:
            for entry in reversed(self.theme.get_theme_dirs()):
                copy_asset(
                    Path(entry, 'static'),
                    self._static_dir,
                    excluded=DOTFILES,
                    context=context,
                    renderer=self.templates,
                    onerror=onerror,
                    force=True,
                )

    def copy_html_static_files(self, context: dict[str, Any]) -> None:
        def onerror(filename: str, error: Exception) -> None:
            logger.warning(
                __('Failed to copy a file in html_static_file: %s: %r'), filename, error
            )

        excluded = Matcher([*self.config.exclude_patterns, '**/.*'])
        for entry in self.config.html_static_path:
            copy_asset(
                self.confdir / entry,
                self._static_dir,
                excluded=excluded,
                context=context,
                renderer=self.templates,
                onerror=onerror,
                force=True,
            )

    def copy_html_logo(self) -> None:
        if self.config.html_logo and not is_url(self.config.html_logo):
            source_path = self.confdir / self.config.html_logo
            copyfile(
                source_path,
                self._static_dir / source_path.name,
                force=True,
            )

    def copy_html_favicon(self) -> None:
        if self.config.html_favicon and not is_url(self.config.html_favicon):
            source_path = self.confdir / self.config.html_favicon
            copyfile(
                source_path,
                self._static_dir / source_path.name,
                force=True,
            )

    def copy_static_files(self) -> None:
        try:
            with progress_message(__('copying static files'), nonl=False):
                # Ensure that the static directory exists
                self._static_dir.mkdir(parents=True, exist_ok=True)

                # prepare context for templates
                context = self.globalcontext.copy()
                if self.indexer is not None:
                    context.update(self.indexer.context_for_searchtool())

                self.create_pygments_style_file()
                self.copy_translation_js()
                self.copy_stemmer_js()
                self.copy_theme_static_files(context)
                self.copy_html_static_files(context)
                self.copy_html_logo()
                self.copy_html_favicon()
        except OSError as err:
            logger.warning(__('cannot copy static file %r'), err)

    def copy_extra_files(self) -> None:
        """Copy html_extra_path files."""
        try:
            with progress_message(__('copying extra files'), nonl=False):
                excluded = Matcher(self.config.exclude_patterns)
                for extra_path in self.config.html_extra_path:
                    copy_asset(
                        self.confdir / extra_path,
                        self.outdir,
                        excluded=excluded,
                        force=True,
                    )
        except OSError as err:
            logger.warning(__('cannot copy extra file %r'), err)

    def write_buildinfo(self) -> None:
        try:
            self.build_info.dump(self.outdir / '.buildinfo')
        except OSError as exc:
            logger.warning(__('Failed to write build info file: %r'), exc)

    def cleanup(self) -> None:
        # clean up theme stuff
        if self.theme:
            self.theme._cleanup()

    def post_process_images(self, doctree: Node) -> None:
        """Pick the best candidate for an image and link down-scaled images to
        their high resolution version.
        """
        super().post_process_images(doctree)

        if self.config.html_scaled_image_link and self.html_scaled_image_link:
            for node in doctree.findall(nodes.image):
                if not any((key in node) for key in ('scale', 'width', 'height')):
                    # resizing options are not given. scaled image link is available
                    # only for resized images.
                    continue
                if isinstance(node.parent, nodes.reference):
                    # A image having hyperlink target
                    continue
                if 'no-scaled-link' in node['classes']:
                    # scaled image link is disabled for this node
                    continue

                uri = node['uri']
                reference = nodes.reference('', '', internal=True)
                if uri in self.images:
                    reference['refuri'] = posixpath.join(self.imgpath, self.images[uri])
                else:
                    reference['refuri'] = uri
                node.replace_self(reference)
                reference.append(node)

    def load_indexer(self, docnames: Set[str]) -> None:
        assert self.indexer is not None
        keep = set(self.env.all_docs).difference(docnames)
        try:
            search_index_path = self.outdir / self.searchindex_filename
            if self.indexer_dumps_unicode:
                with open(search_index_path, encoding='utf-8') as ft:
                    self.indexer.load(ft, self.indexer_format)
            else:
                with open(search_index_path, 'rb') as fb:
                    self.indexer.load(fb, self.indexer_format)
        except (OSError, ValueError):
            if keep:
                logger.warning(
                    __(
                        "search index couldn't be loaded, but not all "
                        'documents will be built: the index will be '
                        'incomplete.'
                    )
                )
        # delete all entries for files that will be rebuilt
        self.indexer.prune(keep)

    def index_page(self, pagename: str, doctree: nodes.document, title: str) -> None:
        # only index pages with title
        if self.indexer is not None and title:
            filename = self.env.doc2path(pagename, base=False)
            metadata = self.env.metadata.get(pagename, {})
            if 'no-search' in metadata or 'nosearch' in metadata:
                self.indexer.feed(pagename, filename, '', new_document(''))
            else:
                self.indexer.feed(pagename, filename, title, doctree)

    def _get_local_toctree(
        self, docname: str, collapse: bool = True, **kwargs: Any
    ) -> str:
        if 'includehidden' not in kwargs:
            kwargs['includehidden'] = False
        if kwargs.get('maxdepth') == '':  # NoQA: PLC1901
            kwargs.pop('maxdepth')
        toctree = global_toctree_for_doc(
            self.env, docname, self, collapse=collapse, **kwargs
        )
        return self.render_partial(toctree)['fragment']

    def get_output_path(self, page_name: str, /) -> Path:
        return Path(self.outdir, page_name + self.out_suffix)

    def get_outfilename(self, pagename: str) -> _StrPath:
        return _StrPath(self.get_output_path(pagename))

    def add_sidebars(self, pagename: str, ctx: dict[str, Any]) -> None:
        def has_wildcard(pattern: str) -> bool:
            return any(char in pattern for char in '*?[')

        matched = None

        # default sidebars settings for selected theme
        sidebars = list(self.theme.sidebar_templates)

        # user sidebar settings
        html_sidebars = self.get_builder_config('sidebars', 'html')
        msg = __('page %s matches two patterns in html_sidebars: %r and %r')
        for pattern, pat_sidebars in html_sidebars.items():
            if patmatch(pagename, pattern):
                if matched and has_wildcard(pattern):
                    # warn if both patterns contain wildcards
                    if has_wildcard(matched):
                        logger.warning(msg, pagename, matched)
                    # else the already matched pattern is more specific
                    # than the present one, because it contains no wildcard
                    continue
                matched = pattern
                sidebars = pat_sidebars

        ctx['sidebars'] = list(sidebars)

    # --------- these are overwritten by the serialization builder

    def get_target_uri(self, docname: str, typ: str | None = None) -> str:
        return quote(docname) + self.link_suffix

    def handle_page(
        self,
        pagename: str,
        addctx: dict[str, Any],
        templatename: str = 'page.html',
        *,
        outfilename: Path | None = None,
        event_arg: Any = None,
    ) -> None:
        ctx = self.globalcontext.copy()
        # current_page_name is backwards compatibility
        ctx['pagename'] = ctx['current_page_name'] = pagename
        ctx['encoding'] = self.config.html_output_encoding
        default_baseuri = self.get_target_uri(pagename)
        # in the singlehtml builder, default_baseuri still contains an #anchor
        # part, which relative_uri doesn't really like...
        default_baseuri = default_baseuri.rsplit('#', 1)[0]

        if self.config.html_baseurl:
            ctx['pageurl'] = posixpath.join(
                self.config.html_baseurl, self.get_target_uri(pagename)
            )
        else:
            ctx['pageurl'] = None

        def pathto(
            otheruri: str,
            resource: bool = False,
            baseuri: str = default_baseuri,
        ) -> str:
            if resource and '://' in otheruri:
                # allow non-local resources given by scheme
                return otheruri
            elif not resource:
                otheruri = self.get_target_uri(otheruri)
            uri = relative_uri(baseuri, otheruri) or '#'
            if uri == '#' and not self.allow_sharp_as_current_path:
                uri = baseuri
            return uri

        ctx['pathto'] = pathto

        def hasdoc(name: str) -> bool:
            if name in self.env.all_docs:
                return True
            if name == 'search' and self.search:
                return True
            return name == 'genindex' and self.get_builder_config('use_index', 'html')

        ctx['hasdoc'] = hasdoc

        ctx['toctree'] = lambda **kwargs: self._get_local_toctree(pagename, **kwargs)
        self.add_sidebars(pagename, ctx)
        ctx.update(addctx)

        # 'blah.html' should have content_root = './' not ''.
        ctx['content_root'] = (f'..{SEP}' * default_baseuri.count(SEP)) or f'.{SEP}'

        outdir = self.app.outdir

        def css_tag(css: _CascadingStyleSheet) -> str:
            attrs = [
                f'{key}="{html.escape(value, quote=True)}"'
                for key, value in css.attributes.items()
                if value is not None
            ]
            uri = pathto(os.fspath(css.filename), resource=True)
            # the EPUB format does not allow the use of query components
            # the Windows help compiler requires that css links
            # don't have a query component
            if self.name not in {'epub', 'htmlhelp'}:
                if checksum := _file_checksum(outdir, css.filename):
                    uri += f'?v={checksum}'
            return f'<link {" ".join(sorted(attrs))} href="{uri}" />'

        ctx['css_tag'] = css_tag

        def js_tag(js: _JavaScript | str) -> str:
            if not isinstance(js, _JavaScript):
                # str value (old styled)
                return f'<script src="{pathto(js, resource=True)}"></script>'

            body = js.attributes.get('body', '')
            attrs = [
                f'{key}="{html.escape(value, quote=True)}"'
                for key, value in js.attributes.items()
                if key != 'body' and value is not None
            ]

            if not js.filename:
                if attrs:
                    return f'<script {" ".join(sorted(attrs))}>{body}</script>'
                return f'<script>{body}</script>'

            js_filename_str = os.fspath(js.filename)
            uri = pathto(js_filename_str, resource=True)
            if 'MathJax.js?' in js_filename_str:
                # MathJax v2 reads a ``?config=...`` query parameter,
                # special case this and just skip adding the checksum.
                # https://docs.mathjax.org/en/v2.7-latest/configuration.html#considerations-for-using-combined-configuration-files
                # https://github.com/sphinx-doc/sphinx/issues/11658
                pass
            # the EPUB format does not allow the use of query components
            elif self.name != 'epub':
                if checksum := _file_checksum(outdir, js.filename):
                    uri += f'?v={checksum}'
            if attrs:
                return f'<script {" ".join(sorted(attrs))} src="{uri}"></script>'
            return f'<script src="{uri}"></script>'

        ctx['js_tag'] = js_tag

        # revert _css_files and _js_files
        self._css_files[:] = self._orig_css_files
        self._js_files[:] = self._orig_js_files

        self.update_page_context(pagename, templatename, ctx, event_arg)
        if new_template := self.app.emit_firstresult(
            'html-page-context', pagename, templatename, ctx, event_arg
        ):
            templatename = new_template

        # sort JS/CSS before rendering HTML
        try:  # NoQA: SIM105
            # Convert script_files to list to support non-list script_files (refs: #8889)
            ctx['script_files'] = sorted(
                ctx['script_files'], key=lambda js: js.priority
            )
        except AttributeError:
            # Skip sorting if users modifies script_files directly (maybe via `html_context`).
            # refs: #8885
            #
            # Note: priority sorting feature will not work in this case.
            pass

        with contextlib.suppress(AttributeError):
            ctx['css_files'] = sorted(ctx['css_files'], key=lambda css: css.priority)

        try:
            output = self.templates.render(templatename, ctx)
        except UnicodeError:
            logger.warning(
                __(
                    'a Unicode error occurred when rendering the page %s. '
                    'Please make sure all config values that contain '
                    'non-ASCII content are Unicode strings.'
                ),
                pagename,
            )
            return
        except Exception as exc:
            msg = __('An error happened in rendering the page %s.\nReason: %r') % (
                pagename,
                exc,
            )
            raise ThemeError(msg) from exc

        if outfilename:
            output_path = Path(outfilename)
        else:
            output_path = self.get_output_path(pagename)
        # The output path is in general different from self.outdir
        ensuredir(output_path.parent)
        try:
            output_path.write_text(
                output, encoding=ctx['encoding'], errors='xmlcharrefreplace'
            )
        except OSError as err:
            logger.warning(__('error writing file %s: %s'), output_path, err)
        if self.copysource and ctx.get('sourcename'):
            # copy the source file for the "show source" link
            source_file_path = self._sources_dir / ctx['sourcename']
            source_file_path.parent.mkdir(parents=True, exist_ok=True)
            copyfile(self.env.doc2path(pagename), source_file_path, force=True)

    def update_page_context(
        self, pagename: str, templatename: str, ctx: dict[str, Any], event_arg: Any
    ) -> None:
        pass

    def handle_finish(self) -> None:
        self.finish_tasks.add_task(self.dump_search_index)
        self.finish_tasks.add_task(self.dump_inventory)

    @progress_message(__('dumping object inventory'))
    def dump_inventory(self) -> None:
        InventoryFile.dump(self.outdir / INVENTORY_FILENAME, self.env, self)

    def dump_search_index(self) -> None:
        if self.indexer is None:
            return

        with progress_message(__('dumping search index in %s') % self.indexer.label()):
            self.indexer.prune(self.env.all_docs)
            search_index_path = self.outdir / self.searchindex_filename
            search_index_tmp = self.outdir / f'{self.searchindex_filename}.tmp'
            # first write to a temporary file, so that if dumping fails,
            # the existing index won't be overwritten
            if self.indexer_dumps_unicode:
                with open(search_index_tmp, 'w', encoding='utf-8') as ft:
                    self.indexer.dump(ft, self.indexer_format)
            else:
                with open(search_index_tmp, 'wb') as fb:
                    self.indexer.dump(fb, self.indexer_format)
            os.replace(search_index_tmp, search_index_path)


def convert_html_css_files(app: Sphinx, config: Config) -> None:
    """Convert string styled html_css_files to tuple styled one."""
    html_css_files: list[tuple[str, dict[str, str]]] = []
    for entry in config.html_css_files:
        if isinstance(entry, str):
            html_css_files.append((entry, {}))
        else:
            try:
                filename, attrs = entry
                html_css_files.append((filename, attrs))
            except Exception:
                logger.warning(__('invalid css_file: %r, ignored'), entry)
                continue

    config.html_css_files = html_css_files


def convert_html_js_files(app: Sphinx, config: Config) -> None:
    """Convert string styled html_js_files to tuple styled one."""
    html_js_files: list[tuple[str, dict[str, str]]] = []
    for entry in config.html_js_files:
        if isinstance(entry, str):
            html_js_files.append((entry, {}))
        else:
            try:
                filename, attrs = entry
                html_js_files.append((filename, attrs))
            except Exception:
                logger.warning(__('invalid js_file: %r, ignored'), entry)
                continue

    config.html_js_files = html_js_files


def setup_resource_paths(
    app: Sphinx,
    pagename: str,
    templatename: str,
    context: dict[str, Any],
    doctree: Node,
) -> None:
    """Set up relative resource paths."""
    pathto = context['pathto']

    # favicon_url
    favicon_url = context.get('favicon_url')
    if favicon_url and not is_url(favicon_url):
        context['favicon_url'] = pathto('_static/' + favicon_url, resource=True)

    # logo_url
    logo_url = context.get('logo_url')
    if logo_url and not is_url(logo_url):
        context['logo_url'] = pathto('_static/' + logo_url, resource=True)


def validate_math_renderer(app: Sphinx) -> None:
    if app.builder.format != 'html':
        return

    name = app.builder.math_renderer_name  # type: ignore[attr-defined]
    if name is None:
        msg = __(
            'Many math_renderers are registered. But no math_renderer is selected.'
        )
        raise ConfigError(msg)
    if name not in app.registry.html_inline_math_renderers:
        raise ConfigError(__('Unknown math_renderer %r is given.') % name)


def validate_html_extra_path(app: Sphinx, config: Config) -> None:
    """Check html_extra_paths setting."""
    html_extra_path = []
    for entry in config.html_extra_path:
        extra_path = (app.confdir / entry).resolve()
        if extra_path.exists():
            if (
                app.outdir.drive == extra_path.drive
                and extra_path.is_relative_to(app.outdir)
            ):  # fmt: skip
                logger.warning(
                    __('html_extra_path entry %r is placed inside outdir'), entry
                )
            else:
                html_extra_path.append(entry)
        else:
            logger.warning(__('html_extra_path entry %r does not exist'), entry)
    config.html_extra_path = html_extra_path


def validate_html_static_path(app: Sphinx, config: Config) -> None:
    """Check html_static_paths setting."""
    html_static_path = []
    for entry in config.html_static_path:
        static_path = (app.confdir / entry).resolve()
        if static_path.exists():
            if (
                app.outdir.drive == static_path.drive
                and static_path.is_relative_to(app.outdir)
            ):  # fmt: skip
                logger.warning(
                    __('html_static_path entry %r is placed inside outdir'), entry
                )
            else:
                html_static_path.append(entry)
        else:
            logger.warning(__('html_static_path entry %r does not exist'), entry)
    config.html_static_path = html_static_path


def validate_html_logo(app: Sphinx, config: Config) -> None:
    """Check html_logo setting."""
    if (
        config.html_logo
        and not (app.confdir / config.html_logo).is_file()
        and not is_url(config.html_logo)
    ):
        logger.warning(__('logo file %r does not exist'), config.html_logo)
        config.html_logo = None


def validate_html_favicon(app: Sphinx, config: Config) -> None:
    """Check html_favicon setting."""
    if (
        config.html_favicon
        and not (app.confdir / config.html_favicon).is_file()
        and not is_url(config.html_favicon)
    ):
        logger.warning(__('favicon file %r does not exist'), config.html_favicon)
        config.html_favicon = None


def error_on_html_sidebars_string_values(app: Sphinx, config: Config) -> None:
    """Support removed in Sphinx 2."""
    errors = {}
    for pattern, pat_sidebars in config.html_sidebars.items():
        if isinstance(pat_sidebars, str):
            errors[pattern] = [pat_sidebars]
    if not errors:
        return
    msg = __(
        "Values in 'html_sidebars' must be a list of strings. "
        'At least one pattern has a string value: %s. '
        'Change to `html_sidebars = %r`.'
    )
    bad_patterns = ', '.join(map(repr, errors))
    fixed = config.html_sidebars | errors
    raise ConfigError(msg % (bad_patterns, fixed))


def error_on_html_4(_app: Sphinx, config: Config) -> None:
    """Error on HTML 4."""
    if config.html4_writer:
        msg = __(
            'HTML 4 is no longer supported by Sphinx. '
            '("html4_writer=True" detected in configuration options)',
        )
        raise ConfigError(msg)


def setup(app: Sphinx) -> ExtensionMetadata:
    # builders
    app.add_builder(StandaloneHTMLBuilder)

    # config values
    app.add_config_value('html_theme', 'alabaster', 'html')
    app.add_config_value('html_theme_path', [], 'html')
    app.add_config_value('html_theme_options', {}, 'html')
    app.add_config_value(
        'html_title',
        lambda c: _('%s %s documentation') % (c.project, c.release),
        'html',
        str,
    )
    app.add_config_value('html_short_title', lambda self: self.html_title, 'html')
    app.add_config_value('html_style', None, 'html', {list, str})
    app.add_config_value('html_logo', None, 'html', str)
    app.add_config_value('html_favicon', None, 'html', str)
    app.add_config_value('html_css_files', [], 'html')
    app.add_config_value('html_js_files', [], 'html')
    app.add_config_value('html_static_path', [], 'html')
    app.add_config_value('html_extra_path', [], 'html')
    app.add_config_value('html_last_updated_fmt', None, 'html', str)
    app.add_config_value('html_last_updated_use_utc', False, 'html', types={bool})
    app.add_config_value('html_sidebars', {}, 'html')
    app.add_config_value('html_additional_pages', {}, 'html')
    app.add_config_value('html_domain_indices', True, 'html', types={set, list})
    app.add_config_value('html_permalinks', True, 'html')
    app.add_config_value('html_permalinks_icon', '¶', 'html')
    app.add_config_value('html_use_index', True, 'html')
    app.add_config_value('html_split_index', False, 'html')
    app.add_config_value('html_copy_source', True, 'html')
    app.add_config_value('html_show_sourcelink', True, 'html')
    app.add_config_value('html_sourcelink_suffix', '.txt', 'html')
    app.add_config_value('html_use_opensearch', '', 'html')
    app.add_config_value('html_file_suffix', None, 'html', str)
    app.add_config_value('html_link_suffix', None, 'html', str)
    app.add_config_value('html_show_copyright', True, 'html')
    app.add_config_value('html_show_search_summary', True, 'html')
    app.add_config_value('html_show_sphinx', True, 'html')
    app.add_config_value('html_context', {}, 'html')
    app.add_config_value('html_output_encoding', 'utf-8', 'html')
    app.add_config_value('html_compact_lists', True, 'html')
    app.add_config_value('html_secnumber_suffix', '. ', 'html')
    app.add_config_value('html_search_language', None, 'html', str)
    app.add_config_value('html_search_options', {}, 'html')
    app.add_config_value('html_search_scorer', '', '')
    app.add_config_value('html_scaled_image_link', True, 'html')
    app.add_config_value('html_baseurl', '', 'html')
    # removal is indefinitely on hold (ref: https://github.com/sphinx-doc/sphinx/issues/10265)
    app.add_config_value(
        'html_codeblock_linenos_style', 'inline', 'html', ENUM('table', 'inline')
    )
    app.add_config_value('html_math_renderer', None, 'env')
    app.add_config_value('html4_writer', False, 'html')

    # events
    app.add_event('html-collect-pages')
    app.add_event('html-page-context')

    # event handlers
    app.connect('config-inited', convert_html_css_files, priority=800)
    app.connect('config-inited', convert_html_js_files, priority=800)
    app.connect('config-inited', validate_html_extra_path, priority=800)
    app.connect('config-inited', validate_html_static_path, priority=800)
    app.connect('config-inited', validate_html_logo, priority=800)
    app.connect('config-inited', validate_html_favicon, priority=800)
    app.connect('config-inited', error_on_html_sidebars_string_values, priority=800)
    app.connect('config-inited', error_on_html_4, priority=800)
    app.connect('builder-inited', validate_math_renderer)
    app.connect('html-page-context', setup_resource_paths)

    # load default math renderer
    app.setup_extension('sphinx.ext.mathjax')

    # load transforms for HTML builder
    app.setup_extension('sphinx.builders.html.transforms')

    return {
        'version': 'builtin',
        'parallel_read_safe': True,
        'parallel_write_safe': True,
    }


# deprecated name -> (object to return, canonical path or empty string, removal version)
_DEPRECATED_OBJECTS: dict[str, tuple[Any, str, tuple[int, int]]] = {
    'Stylesheet': (
        _CascadingStyleSheet,
        'sphinx.builders.html._assets._CascadingStyleSheet',
        (9, 0),
    ),
    'JavaScript': (_JavaScript, 'sphinx.builders.html._assets._JavaScript', (9, 0)),
}


def __getattr__(name: str) -> Any:
    if name not in _DEPRECATED_OBJECTS:
        msg = f'module {__name__!r} has no attribute {name!r}'
        raise AttributeError(msg)

    from sphinx.deprecation import _deprecation_warning

    deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name]
    _deprecation_warning(__name__, name, canonical_name, remove=remove)
    return deprecated_object
